/*
 * Licensed to the Tai Ping Jin Ke
 *
 * Copyright (c) 2022 .
 * All rights reserved.
 * 项目名称：太平金科-基础组件封装层-会话管理-reactor-缺省实现
 * 版权说明：本软件属太平金融科技服务(上海)有限公司所有，在未获得太平金融科技服务(上海)有限公司正式授权情况下，任何企业和个人，不能获取、阅读、安装、传播本软件涉及的任何受知识产权保护的内容。
 */
package com.taipingframework.boot.reactor.session;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.taipingframework.boot.cache.core.ReactiveCacheHandler;
import com.taipingframework.boot.cache.redis.StandardCacheKeyBuilder;
import com.taipingframework.boot.session.support.ClientDetails;
import com.taipingframework.utility.constant.ApplicationConstant;
import com.taipingframework.utility.exception.SystemInterrupterException;
import com.taipingframework.boot.web.response.status.ExceptionStatusEnum;
import com.taipingframework.utility.exception.UtilityException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
import reactor.core.publisher.Mono;

import java.util.Objects;

/**
 * 声明 原始会话管理处理器 的默认行为
 */
@Slf4j
abstract class OriginalSessionHandler {

    /**
     * 标准缓存(读/写)处理器
     */
    ReactiveCacheHandler cacheHandler;
    /**
     * JSON字符串(读/写)对象
     */
    ObjectMapper objectMapper;
    /**
     * JSON字符串(读/写)对象
     */
    ObjectMapper objectMapperForRedis;
    /**
     * HTTP请求头中唯一标识登录用户的参数名
     */
    String authTokenKey;

    OriginalSessionHandler(ReactiveCacheHandler reactiveCacheHandler, ObjectMapper objectMapper) {
        if (Objects.isNull(reactiveCacheHandler)) {
            throw new SystemInterrupterException(ExceptionStatusEnum.UNABLE_GET_CACHE_HANDLER);
        }
        this.cacheHandler = reactiveCacheHandler;

        if (Objects.isNull(objectMapper)) {
            throw new SystemInterrupterException(ExceptionStatusEnum.UNABLE_GET_OBJECT_MAPPER);
        }
        this.objectMapper = objectMapper.copy();
        this.objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        this.objectMapperForRedis = objectMapper.copy();
        this.objectMapperForRedis.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);

        this.authTokenKey = StandardCacheKeyBuilder.getAuthTokenKey();
    }

    /**
     * 创建Session [登录]
     */
    Mono<WebSession> createWebSession(ServerWebExchange exchange) {
        return exchange.getSession()
                .flatMap(webSession -> {
                    // 尝试获取请求头中的token
                    String accessToken = exchange.getRequest().getHeaders().getFirst(authTokenKey);
                    // 获取HTTP请求的远程客户端设备标识
                    String clientIpAddress = exchange.getAttribute(ApplicationConstant.REMOTE_CLIENT_IP_KEY);

                    // 请求头中携带token非空且格式错误
                    if (StringUtils.isNotEmpty(accessToken) && !ClientDetails.validSessionId(accessToken)) {
                        log.error("终端显示层【{}】会话对象标识参数的格式错误：{}", clientIpAddress, accessToken);
                        throw new SystemInterrupterException(ExceptionStatusEnum.INVALID_TOKEN_IN_HTTP_HEADER);
                    }

                    // 当前会话对象已开启且token有效则直接返回 - 请求头中携带的token：① Access-Token为空 ② Access-Token是正确格式的UUID
                    if (webSession.isStarted() && webSession.getId().equals(accessToken)) {
                        // 格式正确的Access-Token没有过期
                        log.debug("终端显示层【{}】的会话对象(session-id：{})在服务器已存在，不执行创建会话对象逻辑", clientIpAddress, accessToken);
                        return Mono.just(webSession);
                    }

                    log.debug("终端显示层【{}】未登录，准备创建会话对象", clientIpAddress);
                    // 请求头中的token无效，开启一个新的会话
                    if (!webSession.isStarted()) {
                        webSession.start();
                    }

                    /*
                     * 此处有个小问题：
                     * 在webSession.save()操作之前，如果添加任意数据到session属性列表中，则spring-session在HTTP响应头中的Token才不会为空。
                     * 否则，该响应头参数的值就是空字符串，该问题需要通过调试源码才能知道原因。
                     * 当然，通过newExchange.getResponse().beforeCommit()方式也能解决该问题，期望还能有更优雅的解决方案。
                     * 认真阅读源码后问题解决，说明：
                     * - webSession.isStarted()返回false 结果会导致在调用webSession.save()时实际上底层不会执行任何操作，也不会将Session-ID发送给客户端；
                     * - webSession.isStarted()返回true 的条件：（1）主动执行方法webSession.start()；（2）或者向webSession添加属性。
                     */
                    String requestId = exchange.getAttribute(ApplicationConstant.RESPONSE_IDENTITY);
                    webSession.getAttributes().putIfAbsent(ApplicationConstant.RESPONSE_IDENTITY, requestId);
                    webSession.getAttributes().putIfAbsent(ApplicationConstant.REMOTE_CLIENT_IP_KEY, clientIpAddress);

                    // 持久化会话对象 - 请求头中携带的token：① Access-Token为空 ② Access-Token格式正确但已经过期
                    return webSession.save()
                            .thenReturn(webSession)
                            .doOnSuccess(vMono -> {
                                // 将会话标识设置到 ServerWebExchange 对象的属性列表中。【在StandardCacheKeyBuilder.java中获取】
                                // 我们约定：如果当前会话对象存在就能够保证在ServerWebExchange.getAttributes()中保存的Access-Token一定存在
                                exchange.getAttributes().putIfAbsent(authTokenKey, webSession.getId());

                                // 将当前HTTP请求的会话对象最大持续时长设置到 ServerWebExchange 对象的属性列表中【在ReactiveCacheHandler.java中获取】
                                exchange.getAttributes().putIfAbsent(ApplicationConstant.SESSION_DURATION_KEY, webSession.getMaxIdleTime().getSeconds());

                                // 根据上下文能够推断出是基于何种场景新建的会话对象
                                if (StringUtils.isNotEmpty(accessToken)) {
                                    log.debug("终端显示层【{}】会话对象(session-id：{})已经过期，重新创建会话对象(session-id：{})成功",
                                            clientIpAddress, accessToken, webSession.getId());
                                } else {
                                    log.debug("终端显示层【{}】创建会话对象(session-id：{})成功", clientIpAddress, webSession.getId());
                                }
                            });
                })
                .onErrorResume(e -> Mono.error(new UtilityException(e)));
    }

    /**
     * 移除Session [注销]
     */
    Mono<Boolean> removeWebSession(ServerWebExchange exchange) {
        return exchange.getSession()
                .flatMap(webSession -> {
                    // 尝试获取请求头中的token
                    String accessToken = exchange.getRequest().getHeaders().getFirst(authTokenKey);
                    // 获取HTTP请求的远程客户端设备标识
                    String clientIpAddress = exchange.getAttribute(ApplicationConstant.REMOTE_CLIENT_IP_KEY);

                    if (StringUtils.isEmpty(accessToken)) {
                        log.error("终端显示层【{}】的HTTP请求头没有携带会话对象标识参数。", clientIpAddress);
                        // 请求头中没有携带token
                        throw new SystemInterrupterException(ExceptionStatusEnum.WITHOUT_TOKEN_IN_HTTP_HEADER);
                    }
                    if (!ClientDetails.validSessionId(accessToken)) {
                        log.error("终端显示层【{}】会话对象标识参数的格式错误：{}", clientIpAddress, accessToken);
                        // 请求头中携带token，但格式错误
                        throw new SystemInterrupterException(ExceptionStatusEnum.INVALID_TOKEN_IN_HTTP_HEADER);
                    }

                    if (webSession.isStarted() && webSession.getId().equals(accessToken)) {
                        // 当前会话对象是启动动态，则使其失效
                        return webSession.invalidate()
                                .then(Mono.just(true))
                                .doOnSuccess(flag -> log.debug("终端显示层【{}】会话对象(session-id：{})注销成功", clientIpAddress, accessToken));
                    }

                    // 请求头中携带token，但已经过期，nothing to do.
                    log.debug("终端显示层【{}】会话对象(session-id：{})已经过期，不执行注销逻辑", clientIpAddress, accessToken);
                    return Mono.just(false);
                })
                .onErrorResume(e -> Mono.error(new UtilityException(e)));
    }

    /**
     * 获取当前会话对象[登录标识]
     * <p>
     * 特别说明：spring-httpSession与spring-webSession表现在应用层的最终效果一致，
     * 但底层实现天差地别，在对它们进行封装时，编码风格以及编码组织方式都存在很大的差异！
     */
    Mono<WebSession> readWebSession(ServerWebExchange exchange) {
        return exchange.getSession()
                .flatMap(webSession -> {
                    // 尝试获取请求头中的token
                    String accessToken = exchange.getRequest().getHeaders().getFirst(authTokenKey);
                    if (webSession.isStarted() && webSession.getId().equals(accessToken)) {
                        // 当前会话对象是处于启动状态，直接返回
                        return Mono.just(webSession);
                    }

                    /*
                     * 假如，在上面步骤中没有获取到session：
                     * （1）当前HTTP请求头没有携带token，需要抛异常；
                     * （2）当前HTTP请求头携带的token不是UUID类型，需要抛异常；
                     * （3）当前HTTP请求头携带了token，但是长时间未操作导致终端显示层的会话对象在服务器已经过期，也需要抛异常；
                     */
                    // 获取HTTP请求的远程客户端设备标识
                    String clientIpAddress = exchange.getAttribute(ApplicationConstant.REMOTE_CLIENT_IP_KEY);

                    // 请求头中没有携带token
                    if (StringUtils.isEmpty(accessToken)) {
                        log.error("终端显示层【{}】的HTTP请求头没有携带会话对象标识参数。", clientIpAddress);
                        throw new SystemInterrupterException(ExceptionStatusEnum.WITHOUT_TOKEN_IN_HTTP_HEADER);
                    } else if (ClientDetails.validSessionId(accessToken)) {
                        log.error("终端显示层【{}】会话对象(session-id：{})已经过期", clientIpAddress, accessToken);
                        // 请求头中携带token，但已经过期
                        throw new SystemInterrupterException(ExceptionStatusEnum.EXPIRED_TOKEN_IN_HTTP_HEADER);
                    } else {
                        log.error("终端显示层【{}】会话对象标识参数的格式错误：{}", clientIpAddress, accessToken);
                        // 请求头中携带token，但格式错误
                        throw new SystemInterrupterException(ExceptionStatusEnum.INVALID_TOKEN_IN_HTTP_HEADER);
                    }
                })
                .onErrorResume(e -> Mono.error(new UtilityException(e)));
    }

}
